嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第四天!
今天我們要來探討 Rust 最核心、也可能是最讓初學者困惑的概念:所有權 (Ownership)。如果說前三天我們學的是 Rust 的「語法」,那麼今天要學的就是 Rust 的「靈魂」。
老實說,當我第一次接觸所有權這個概念時,腦袋裡滿滿都是問號。畢竟在 C# 或 JavaScript 的世界裡,我們很少需要思考「誰擁有這個變數」或「什麼時候記憶體會被釋放」,因為都有垃圾回收器(GC)幫我們處理。但在 Rust 的世界裡,沒有 GC,也不需要我們手動管理記憶體,那記憶體到底是怎麼管理的呢?
答案就是:所有權系統。
在深入了解所有權之前,讓我們先思考一下記憶體管理的幾種方式:
// C 語言的例子
char* message = malloc(100); // 手動分配記憶體
strcpy(message, "Hello");
// ... 使用 message
free(message); // 手動釋放記憶體
優點:完全控制,效能極佳
缺點:容易出錯(忘記釋放、重複釋放、使用已釋放的記憶體)
// C# 的例子
string message = "Hello"; // 系統自動管理記憶體
// GC 會在適當時機自動回收不再使用的記憶體
優點:程式設計簡單,不易出錯
缺點:執行時開銷、GC 暫停、無法精確控制記憶體釋放時機
// Rust 的例子
let message = String::from("Hello"); // 系統自動管理記憶體
// 當 message 離開作用域時,記憶體自動釋放
優點:結合了手動管理的效能與垃圾回收的安全性
缺點:學習曲線較陡峭
Rust 的所有權系統建立在三個簡單但強大的規則之上:
讓我們用實際的例子來理解這些規則:
fn main() {
{ // s 還不存在
let s = "hello"; // s 進入作用域
// 在這裡使用 s
} // s 離開作用域,被釋放
// println!("{}", s); // 錯誤!s 已經不存在了
}
這個例子看起來很簡單,但其實展示了 Rust 如何自動管理記憶體。當變數 s
離開作用域時,Rust 會自動呼叫一個特殊的函式 drop
,清理該變數占用的記憶體。
這裡就開始有趣了。讓我們看看會發生什麼:
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 的值被「移動」到 s2
// println!("{}", s1); // 編譯錯誤!s1 已經無效了
println!("{}", s2); // 這個沒問題
}
咦?為什麼 s1
突然就無效了?這就是 Rust 所有權系統的核心:移動語意(Move Semantics)。
讓我們比較一下不同型別的行為:
fn main() {
let x = 5;
let y = x; // x 被複製到 y
println!("x = {}, y = {}", x, y); // 都可以使用
}
這裡沒問題,因為像 i32
這樣的基本型別實現了 Copy
trait,所以會被複製而不是移動。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 被移動到 s2
// println!("{}", s1); // 錯誤!s1 已經無效
}
為什麼會這樣?讓我們看看 String
在記憶體中的結構:
fn main() {
let s1 = String::from("hello");
// s1 在 stack 上存放:
// - 指向 heap 記憶體的 pointer
// - 長度 (5)
// - 容量 (5)
//
// 實際的字串資料 "hello" 存放在 heap 上
let s2 = s1; // 只複製 stack 上的資料(pointer、長度、容量)
// 但 s1 變得無效,避免雙重釋放 (double free)
}
如果 Rust 允許 s1
和 s2
同時有效,那麼當它們都離開作用域時,就會嘗試釋放同一塊堆積記憶體兩次,這會導致「雙重釋放」的錯誤。
所有權在函式呼叫時也會發生轉移:
fn main() {
let s = String::from("hello");
takes_ownership(s); // s 的所有權轉移給函式
// println!("{}", s); // 錯誤!s 已經無效
let x = 5;
makes_copy(x); // x 被複製,原本的 x 仍然有效
println!("x = {}", x); // 沒問題
}
fn takes_ownership(some_string: String) {
println!("{}", some_string);
} // some_string 離開作用域,記憶體被釋放
fn makes_copy(some_integer: i32) {
println!("{}", some_integer);
} // some_integer 離開作用域,但因為是 Copy 型別,沒有特殊處理
函式也可以轉移所有權給呼叫者:
fn main() {
let s1 = gives_ownership(); // 函式回傳值的所有權轉移給 s1
let s2 = String::from("hello");
let s3 = takes_and_gives_back(s2); // s2 被移動進函式,回傳值移動給 s3
// println!("{}", s2); // 錯誤!s2 已經被移動
println!("{}", s1); // 沒問題
println!("{}", s3); // 沒問題
}
fn gives_ownership() -> String {
let some_string = String::from("yours");
some_string // 回傳值,所有權轉移給呼叫者
}
fn takes_and_gives_back(a_string: String) -> String {
a_string // 回傳傳入的值,所有權轉移給呼叫者
}
讓我們寫一個實際的例子來加深理解:## 如何解決所有權轉移的問題?
你可能會想:「這樣不是很麻煩嗎?每次把變數傳給函式就不能再用了?」
確實,如果每次都要轉移所有權,程式會變得很難寫。幸好 Rust 提供了幾種解決方案:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // 明確地複製資料
println!("s1 = {}, s2 = {}", s1, s2); // 都可以使用
}
clone()
會建立資料的深度複製,但這有效能成本。
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // 傳遞引用,不轉移所有權
println!("'{}' 的長度是 {}", s1, len); // s1 仍然可以使用
}
fn calculate_length(s: &String) -> usize {
s.len()
} // s 是引用,不擁有資料,所以不會釋放記憶體
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length_and_return(s1);
println!("'{}' 的長度是 {}", s2, len);
}
fn calculate_length_and_return(s: String) -> (String, usize) {
let length = s.len();
(s, length) // 回傳所有權
}
看看下面這些程式碼,猜猜哪些會編譯成功,哪些會失敗:
// 程式碼片段 1
fn quiz_1() {
let x = 5;
let y = x;
println!("{} {}", x, y);
}
// 程式碼片段 2
fn quiz_2() {
let s = String::from("hello");
let t = s;
println!("{} {}", s, t);
}
// 程式碼片段 3
fn quiz_3() {
let s = String::from("hello");
takes_string(s);
println!("{}", s);
}
fn takes_string(s: String) {
println!("{}", s);
}
// 程式碼片段 4
fn quiz_4() {
let s = String::from("hello");
let s = s.clone();
println!("{}", s);
}
答案:
今天我們學會了 Rust 最核心的概念:
所有權的三大規則:
關鍵概念:
為什麼這樣設計?
雖然一開始可能覺得限制很多,但這些規則確保了我們的程式在編譯時就沒有記憶體安全問題。一旦習慣了,你會發現這樣的程式碼更容易理解和維護。
寫一個程式,實作以下功能:
process_string
,接收一個 String
String
main
函式中測試這個函式,確保原本的字串在函式呼叫後不能再使用範例程式碼框架:
fn process_string(/* 在這裡填入參數 */) -> String {
// 在這裡實作字串處理邏輯
}
fn main() {
let original = String::from("hello world");
let result = process_string(/* 在這裡傳入參數 */);
println!("處理結果: {}", result);
// 嘗試使用原本的字串(這應該要編譯失敗)
// println!("原本的字串: {}", original);
}
提示:
String
還是 &String
?format!
巨集來組合字串期望的輸出:
處理結果: processed: hello world!
而且 println!("原本的字串: {}", original);
這行如果取消註解應該要編譯失敗!
這個挑戰會讓你實際體驗所有權轉移,以及為什麼我們需要引用與借用(明天的主題)!
明天我們將學習引用與借用,這會解決今天遇到的許多所有權轉移的問題,讓我們能更靈活地使用資料而不需要轉移所有權。
如果今天的內容讓你覺得有點頭痛,別擔心,這是完全正常的!所有權是 Rust 最獨特的概念,需要時間消化。多寫幾個例子,很快就會開始有感覺了。
我們明天見!